AbortController로 오래 걸리는 비동기 작업 취소하기

AbortController로 오래 걸리는 비동기 작업 취소하기

사용자가 화면을 떠났는데 요청이 계속 실행되거나, 서버의 제한 시간을 넘긴 외부 API가 연결을 붙잡고 있으면 자원이 낭비된다. Promise 자체에는 보편적인 cancel 메서드가 없기 때문에 작업이 AbortSignal을 받아들이도록 설계해야 한다.

한눈에 보기

  • AbortController는 취소 상태를 만들고 AbortSignal은 그 상태를 작업에 전달한다.
  • timeout과 사용자 취소를 같은 신호 체계로 다룰 수 있다.
  • 취소는 실패와 구분하되 정리 코드는 반드시 실행해야 한다.

목차

fetch 요청에 시간 제한 넣기

async function fetchWithTimeout(url: string, timeoutMs: number) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);

  try {
    return await fetch(url, { signal: controller.signal });
  } finally {
    clearTimeout(timer);
  }
}

finally에서 타이머를 정리하지 않으면 요청이 빨리 끝나도 불필요한 타이머가 남는다. 최신 런타임의 AbortSignal.timeout을 사용할 수도 있지만 지원 범위를 확인한다.

여러 작업에 취소 전달하기

상위 서비스가 받은 signal을 DB 드라이버, HTTP 클라이언트, 반복 작업에 계속 전달해야 실제 취소가 전파된다.

async function syncUser(userId: string, signal: AbortSignal) {
  signal.throwIfAborted();
  const profile = await loadProfile(userId, { signal });
  signal.throwIfAborted();
  return saveProfile(profile, { signal });
}
취소할 수 없는 작업

이미 DB에 커밋된 변경이나 외부 서비스가 받은 요청은 signal만으로 되돌아가지 않는다. 보상 작업과 멱등성 키가 별도로 필요하다.

취소 시 정리할 것

열린 파일, 임시 디렉터리, 스트림 listener, 타이머를 해제한다. 오류 로깅에서는 AbortError를 장애와 같은 수준으로 기록하지 않도록 구분한다.

timeout과 사용자 취소를 하나로 합치기

실제 요청에는 사용자가 누른 취소 버튼과 상위 요청의 종료, 자체 timeout이 동시에 존재할 수 있다. 여러 신호 중 하나라도 abort되면 하위 작업을 중단하도록 합성할 수 있다.

export async function loadPreview(url, parentSignal) {
  const timeoutSignal = AbortSignal.timeout(3_000);
  const signal = AbortSignal.any([parentSignal, timeoutSignal]);

  const response = await fetch(url, { signal });
  if (!response.ok) {
    throw new Error(`Preview request failed: ${response.status}`);
  }
  return response.json();
}

AbortSignal.anyAbortSignal.timeout은 사용하는 런타임의 지원 범위를 확인해야 한다. 지원하지 않는 환경에서는 controller를 하나 만들고 각 원본 신호의 abort 이벤트를 전달하는 작은 helper를 둘 수 있다.

function combineSignals(signals) {
  const controller = new AbortController();

  const abort = (signal) => {
    if (!controller.signal.aborted) {
      controller.abort(signal.reason);
    }
  };

  for (const signal of signals) {
    if (signal.aborted) abort(signal);
    else signal.addEventListener("abort", () => abort(signal), { once: true });
  }

  return controller.signal;
}

서버 요청에서 하위 호출로 전달하기

클라이언트 연결이 끊어졌는데 서버가 비싼 외부 호출을 계속 수행할 필요가 없는 경우가 있다. 요청 생명주기에서 controller를 만들고 하위 계층으로 전달한다.

app.get("/preview", async (request, response, next) => {
  const controller = new AbortController();

  request.on("close", () => {
    controller.abort(new Error("client disconnected"));
  });

  try {
    const preview = await previewService.load(request.query.url, {
      signal: controller.signal,
    });
    response.json(preview);
  } catch (error) {
    if (controller.signal.aborted) return;
    next(error);
  }
});

이 코드는 개념을 위한 예시이며 프레임워크와 Node.js 버전에 따라 연결 종료 이벤트를 다르게 다뤄야 한다. 중요한 점은 signal이 Controller에서 서비스, HTTP client까지 끊기지 않고 전달되어야 한다는 것이다.

flowchart LR
    A[Client disconnect] --> B[Request AbortController]
    B --> C[Service signal]
    C --> D[HTTP fetch]
    C --> E[Long loop]
    C --> F[Stream pipeline]

반복 작업도 스스로 신호를 확인해야 한다

fetch처럼 signal을 직접 받는 API가 아니면 작업 코드가 중간중간 확인해야 한다.

async function transformRows(rows, { signal }) {
  const output = [];

  for (const [index, row] of rows.entries()) {
    signal.throwIfAborted();
    output.push(transformRow(row));

    if (index % 200 === 0) {
      await new Promise((resolve) => setImmediate(resolve));
    }
  }

  return output;
}

CPU를 오래 점유하는 동기 함수 하나 안에서는 abort 이벤트도 실행될 수 없다. 작업을 나누거나 Worker Thread로 옮긴 뒤 취소 메시지를 전달해야 한다.

abort와 rollback은 다르다

1. 외부 결제 요청 전송
2. 클라이언트 연결 종료
3. 로컬 fetch abort
4. 결제 서버에서는 이미 승인 완료

로컬에서 응답 대기를 취소했다고 외부 시스템의 변경이 되돌아가는 것은 아니다. 이 상황에서는 요청 ID로 결과를 조회하거나 보상 작업을 실행해야 한다.

작업 abort로 가능한 것 별도로 필요한 것
아직 전송하지 않은 요청 시작 방지 없음
진행 중 fetch 응답 대기와 연결 취소 서버 처리 여부 확인
DB 트랜잭션 드라이버 지원 시 쿼리 취소 rollback 확인
이미 커밋된 결제 되돌릴 수 없음 멱등성·취소 거래
임시 파일 생성 후속 작업 중단 파일 삭제 cleanup
취소를 정상 상태로 기록한다

사용자 취소를 서버 장애와 같은 오류율에 넣으면 알람이 왜곡된다. cancelled, timed_out, failed를 구분해서 관측하는 편이 좋다.

결론

취소는 UI 편의 기능이 아니라 자원과 작업 생명주기를 통제하는 API 설계다. 최상위 요청에서 만든 신호가 하위 작업까지 전달되는지 확인한다.

관련 노트